{
 "cells": [
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import sys\n",
    "sys.path.append('~/EquiScore') # i recommand to use absolute path!\n",
    "\n",
    "import pickle\n",
    "from Train import *\n",
    "from torch import distributed as dist\n",
    "import torch.multiprocessing as mp\n",
    "from utils.dist_utils import *\n",
    "import utils.utils as utils\n",
    "import random\n",
    "\n",
    "from argparse import ArgumentParser,FileType\n",
    "# from utils.parsing import parse_train_args\n",
    "parser = ArgumentParser()\n",
    "# parser.add_argument('--config', type=FileType(mode='r'), default=None)\n",
    "parser.add_argument('--save_dir', type=str, default='workdir', help='Folder in which to save model and logs')\n",
    "parser.add_argument('--local_rank', type=int, default = -1,help='local rank for DistributedDataParallel')\n",
    "parser.add_argument('--save_model', type=str, default = './workdir/official_weight/save_model_screen.pt',help='model for test or restart training')\n",
    "parser.add_argument('--lmdb_cache', type=str, default='./lmdbs/PDBscreen', help='Folder containing trainging data in lmdb format')\n",
    "parser.add_argument('--data_path', type=str, default='./EquiScore/data/training_data/PDBscreen', help='Folder containing trainging data')\n",
    "parser.add_argument('--debug', action='store_true', default=False, help='debug mode for EquiScore')\n",
    "parser.add_argument('--hot_start', action='store_true', default=False, help='Continue training on the basis of the original please set save_model to continue training')\n",
    "# Dataset\n",
    "parser.add_argument('--train_keys', type=str, default='./data/data_splits/screen_model/train_keys.pkl', help='Path of file defining the split')\n",
    "parser.add_argument('--val_keys', type=str, default='./data/data_splits/screen_model/val_keys.pkl', help='Path of file defining the split')\n",
    "parser.add_argument('--test_keys', type=str, default='./data/data_splits/screen_model/test_keys.pkl', help='Path of file defining the split')\n",
    "parser.add_argument('--train_val_mode', type=str, default='uniport_cluster', help='data split mode')\n",
    "# parser.add_argument('--test_keys', type=str, default='./data/test_keys', help='Path of file defining the split')\n",
    "# distributed training\n",
    "parser.add_argument('--ngpu', type=int, default=1, help='Number of gpu for training')\n",
    "parser.add_argument('--MASTER_ADDR', type=str, default='localhost', help='localhost or ip address')\n",
    "parser.add_argument('--MASTER_PORT', type=str, default='29505', help='port number')\n",
    "# Training arguments\n",
    "parser.add_argument('--epoch', type=int, default=400, help='Number of epochs for training')\n",
    "parser.add_argument('--seed', type=int, default=42, help='random seed')\n",
    "parser.add_argument('--loss_fn', type=str, default='cross_entry',\\\n",
    "                    choices=['bce_loss','focal_loss','cross_entry','mse_loss','poly_loss_ce','poly_loss_fl'], help='loss function')\n",
    "parser.add_argument('--grad_sum', type=int, default=1, help='Number of grad accumulation steps')\n",
    "parser.add_argument('--label_smothing', type=float, default=0.0, help='label smothing coefficient')\n",
    "parser.add_argument('--eps', type=float, default=4.0, help='focal loss eps')\n",
    "parser.add_argument('--batch_size', type=int, default=64, help='Batch size on each gpu')\n",
    "parser.add_argument('--sampler', action='store_true', default=False, help='dynamic sampler or not')\n",
    "parser.add_argument('--scheduler', type=str, default=None, help='LR scheduler')\n",
    "parser.add_argument('--patience', type=int, default=50, help='Patience of early stopping')\n",
    "parser.add_argument('--lr', type=float, default=0.0001, help='Initial learning rate')\n",
    "parser.add_argument('--lr_decay', action='store_true', default=True, help='dynamic sampler or not')\n",
    "parser.add_argument('--max_lr', type=float, default=0.001, help='max learning rate') \n",
    "parser.add_argument('--pct_start', type=float, default=0.3, help='OneCycleLR parameter')\n",
    "parser.add_argument('--num_workers', type=int, default=8, help='Number of workers for preprocessing')\n",
    "# Model\n",
    "parser.add_argument('--model', type=str, default='EquiScore', help='model class')\n",
    "parser.add_argument('--pred_mode', type=str, default='ligand', help='prediction mode')\n",
    "parser.add_argument('--n_graph_layer', type=int, default=2, help='Number of EquiScore layers')\n",
    "parser.add_argument('--threshold', type=float, default=5.5, help='Radius cutoff for geometric diatance based graph')\n",
    "parser.add_argument('--n_FC_layer', type=int, default=4, help='Number of linear layers')\n",
    "parser.add_argument('--d_FC_layer', type=int, default=256, help='dims of linear layers')\n",
    "parser.add_argument('--n_in_feature', type=int, default=128, help='dims of input features in EquiScore')\n",
    "parser.add_argument('--n_out_feature', type=int, default=128, help='dims of output features in EquiScore')\n",
    "parser.add_argument('--ffn_size', type=int, default=280, help='dims of FFN layers in EquiScore')\n",
    "parser.add_argument('--dropout_rate', type=float, default=0.1, help='dropout_rate in EquiScore')\n",
    "parser.add_argument('--attention_dropout_rate', type=float, default=0.1, help='dropout_rate in Attention module')\n",
    "parser.add_argument('--head_size', type=int, default=8, help='number of attention heads in EquiScore')\n",
    "parser.add_argument('--pos_enc_dim', type=int, default=16, help='position encoding dims')\n",
    "parser.add_argument('--edge_dim', type=int, default=64, help='edge feature dims')\n",
    "parser.add_argument('--layer_norm', action='store_true', default=True, help='layer_norm or not')\n",
    "parser.add_argument('--graph_norm', action='store_true', default=True, help='graph_norm or not')\n",
    "parser.add_argument('--residual', action='store_true', default=True, help='residual or not')\n",
    "parser.add_argument('--edge_bias', action='store_true', default=True, help='covalent bond informations')\n",
    "parser.add_argument('--rel_3d_pos_bias', action='store_true', default=True, help='3d distance informations')\n",
    "parser.add_argument('--in_degree_bias', action='store_true', default=True, help='indegree infomations')\n",
    "parser.add_argument('--virtual_aromatic_atom', action='store_true', default=True, help='add virtual aromatic atom')\n",
    "parser.add_argument('--fingerprintEdge', action='store_true', default=True, help='construct edge based fingerprint information by proLIF ')\n",
    "parser.add_argument('--FP', action='store_true', default=True, help='use AttentiveFP feature or graphformer feature')\n",
    "parser.add_argument('--rel_pos_bias', action='store_true', default=False, help=' shortest path distance informations')\n",
    "parser.add_argument('--lap_pos_enc', action='store_true', default=False, help='laplace position infomations')\n",
    "# Benchmark test and Screen compounds for a target protein\n",
    "parser.add_argument('--test', action='store_true', default=False, help='test mode for banchmark test or screen compounds')\n",
    "parser.add_argument('--test_mode', type = str,default='one_pose', choices=['one_pose','multi_pose'],help=\"if docking result one pose for a ligand set 'one_pose' or multi poses for a ligand set multi_pose\")\n",
    "parser.add_argument('--test_flag', type = str,default='external_test',help=\"anything can help you to identify the test result\")\n",
    "parser.add_argument('--idx_style', action='store_true', default=False, help='for multi_pose mode to select multi pose test or one pose test in the pose number')\n",
    "parser.add_argument('--pose_num', type=int, default=1, \\\n",
    "                    help='select the pose number for multi_pose mode test ,for example : \\\n",
    "                        if pose_num = 3, idx_style=true, just the 3rd pose for test,\\\n",
    "                        but if idx_style=false, the first 3 poses will be test')\n",
    "parser.add_argument('--test_path', type=str, default=\"./data/sample_data/\", \\\n",
    "                    help='test directory which contains the banchmark test data dirs')\n",
    "parser.add_argument('--test_name', type=str, default=\"tmp_pockets\", \\\n",
    "                    help='test dataset directory which contains the test pockets')\n",
    "parser.add_argument('--EF_rates', type=list, default=[0.001,0.002,0.005,0.01,0.02,0.05,0.1,0.2,0.5], \\\n",
    "                    help='EF rates for banchmark test')\n",
    "parser.add_argument('--pred_save_path', type=str, default=\"./data/test_results/EquiScore_pred_for_tmp_pockets.csv\", \\\n",
    "                    help='path for prediction results in Screening script')\n",
    "args = parser.parse_args([])\n",
    "# in notebook you should add a '[]' to  args = parser.parse_args() in parse_train_args function\n",
    "seed_torch(seed = args.seed)\n",
    "args.ngpu = 1"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "'''\n",
    "util funcs\n",
    "\n",
    "'''\n",
    "import seaborn as sns\n",
    "import torch.nn.functional as F\n",
    "import matplotlib.pyplot as plt\n",
    "from dgl.nn.functional import edge_softmax\n",
    "def MeanStd(data):\n",
    "    max_value = max(data)\n",
    "    min_value = min(data)\n",
    "    X_std = (data - min_value)/((max_value-min_value) + 1e-11)\n",
    "    X_scaled  =  X_std*(max_value - min_value) + min_value\n",
    "    return X_scaled/X_scaled.sum()\n",
    "def MaxMin(atom_weights):\n",
    "    min_value = min(atom_weights)\n",
    "    max_value = max(atom_weights)\n",
    "    atom_weights = (atom_weights - min_value) / (max_value - min_value)\n",
    "    return atom_weights\n",
    "def plotAttDist(pred_g):\n",
    "    score = edge_softmax(graph = pred_g,logits = pred_g.edata['score'])\n",
    "    atts = score.squeeze(-1).mean(1)\n",
    "    plt.plot(atts)\n",
    "def copyAtt(pred_g):\n",
    "    atts_copy = np.zeros(shape = (len(pred_g.nodes()),len(pred_g.nodes())))\n",
    "    u,v = pred_g.edges()\n",
    "    eids = pred_g.edge_ids(u,v)\n",
    "    atts_copy[u,v]= atts[eids]\n",
    "    return atts_copy\n",
    "def getAttnFP(pred_g,fp,n1):\n",
    "\n",
    "    temp_fp= np.array(fp)\n",
    "    u,v = list(temp_fp[:,0]) +  list((n1+ temp_fp[:,1])),list((n1+ temp_fp[:,1])) + list(temp_fp[:,0])\n",
    "    eids = pred_g.edge_ids(u,v)\n",
    " \n",
    "    all_ids = list(range(pred_g.num_edges()))\n",
    "    eid_res = torch.tensor(list(set(all_ids) - set(eids.data.cpu().numpy()))).to(eids.device)\n",
    "    \n",
    "    score = edge_softmax(graph = pred_g,logits = pred_g.edata['score'].clamp(-5,5))\n",
    "    attnFP = score[eids]\n",
    "    attnFP_res = score[eid_res]\n",
    "    return attnFP,attnFP_res\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# select keys to compute \n",
    "def getDataLoader(args,key_type= 'active',nums = 10000):\n",
    "    try:\n",
    "        with open (args.test_keys, 'rb') as fp:\n",
    "            keys = pickle.load(fp)\n",
    "    except:\n",
    "        keys = args.test_keys\n",
    "    test_actives = [i for i in keys if '_active'  in i.split('/')[-1] ]\n",
    "    test_decoys = [i for i in keys if '_active' not in i.split('/')[-1]]\n",
    "    all_pocket =  test_actives + test_decoys \n",
    "    random.shuffle(all_pocket)\n",
    "    random.shuffle(test_actives)\n",
    "    random.shuffle(test_decoys)\n",
    "    \n",
    "    print(f'actives num : {len(test_actives)} decoys num : {len(test_decoys)}')\n",
    "    if key_type== 'active':\n",
    "        test_keys = test_actives[:nums]\n",
    "    elif key_type== 'decoy' :\n",
    "        test_keys = test_decoys[:nums]\n",
    "    else:\n",
    "        test_keys = all_pocket[:nums]\n",
    "    test_dataset = ESDataset(test_keys,args, args.data_path,args.debug)\n",
    "    test_sampler = SequentialDistributedSampler(test_dataset,1) if args.ngpu > 1 else None\n",
    "    test_dataloader = DataLoaderX(test_dataset, 1, sampler=test_sampler,prefetch_factor = 4,\\\n",
    "        shuffle=False, num_workers = 1, collate_fn=test_dataset.collate,pin_memory=True) \n",
    "    return test_sampler,test_dataloader,test_keys"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def getModel(args):\n",
    "    if args.FP:\n",
    "        args.N_atom_features = 39\n",
    "    else:\n",
    "        args.N_atom_features = 28\n",
    "    #hyper parameters\n",
    "    num_epochs = args.epoch\n",
    "    lr = args.lr\n",
    "    ngpu = 1\n",
    "    args.ngpu = 1\n",
    "    batch_size = args.batch_size = 1\n",
    "    data_path = args.data_path\n",
    "    save_dir = args.save_dir\n",
    "    train_time = time.strftime('%Y-%m-%d-%H-%M-%S')\n",
    "    #make save dir if it doesn't exist\n",
    "    #initialize model\n",
    "    if args.ngpu>0:\n",
    "        cmd = get_available_gpu(num_gpu=args.ngpu, min_memory=8000, sample=3, nitro_restriction=False, verbose=True)\n",
    "\n",
    "        if cmd[-1] == ',':\n",
    "            os.environ['CUDA_VISIBLE_DEVICES']=cmd[:-1]\n",
    "        else:\n",
    "            os.environ['CUDA_VISIBLE_DEVICES']=cmd\n",
    "        print(cmd)\n",
    "    model = EquiScore(args) if args.model == 'EquiScore' else None\n",
    "    print ('number of parameters : ', sum(p.numel() for p in model.parameters() if p.requires_grad))\n",
    "    args.device = 'cuda:0'\n",
    "    args.local_rank = 'cuda:0'\n",
    "    model ,opt_dict,epoch_start= utils.initialize_model(model, args.device,args,args.save_model)\n",
    "    return model"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from rdkit import Chem\n",
    "from rdkit.Chem import Draw\n",
    "from rdkit.Chem.Draw import IPythonConsole\n",
    "from rdkit.Chem.Draw import SimilarityMaps\n",
    "import  rdkit.Chem.AllChem as AllChem\n",
    "IPythonConsole.ipython_useSVG = True\n",
    "import copy\n",
    "from rdkit.Chem import rdDepictor\n",
    "from rdkit.Chem.Draw import rdMolDraw2D\n",
    "from IPython.display import SVG\n",
    "from IPython.display import display\n",
    "import matplotlib\n",
    "import matplotlib.cm as cm\n",
    "def InchMap(mol):\n",
    "    inchi, aux_info = Chem.MolToInchiAndAuxInfo(mol)\n",
    "    for i in aux_info.split('/'):\n",
    "        if i[0]=='N':\n",
    "            # print(i)\n",
    "            pos=i[2:].split(',')\n",
    "    inchi_to_mol = {i:int(j)-1 for i,j in enumerate(pos)}\n",
    "    mol_to_inchi = {int(j)-1 :i for i,j in enumerate(pos)}\n",
    "    return inchi_to_mol,mol_to_inchi\n",
    "def mol_with_atom_weight( mol ,weights,mol_to_inchi):\n",
    "    atoms = mol.GetNumAtoms()\n",
    "    for idx in range( atoms ):\n",
    "\n",
    "        mol.GetAtomWithIdx( idx ).SetProp(\"atomNote_Score\", f\"{str( round(float(weights[mol_to_inchi[mol.GetAtomWithIdx( idx ).GetIdx()]]),3))}\")\n",
    "\n",
    "    return mol\n",
    "\n",
    "def drawmol(mol,atom_weights,highlightAtoms = None,flag = ''):\n",
    "    inchi_to_mol,mol_to_inchi = InchMap(mol)\n",
    "    weight_inchi = []\n",
    "    for i in range(len(atom_weights)):\n",
    "        try:\n",
    "            weight_inchi.append(atom_weights[inchi_to_mol[i]])\n",
    "        except:\n",
    "            weight_inchi.append(atom_weights[i])\n",
    "                                        \n",
    "    mol = Chem.MolFromSmiles(Chem.MolToSmiles(mol))\n",
    "    weight_inchi = MaxMin(weight_inchi)\n",
    "    \n",
    "    note =flag +  '  vitual node weight:'\n",
    "    for i in range(len(mol.GetAtoms()),len(weight_inchi)):\n",
    "        note += f' {i - len(mol.GetAtoms()) + 1} : {round(weight_inchi[i],3)} '\n",
    "        \n",
    "\n",
    "    mol = mol_with_atom_weight( mol ,weight_inchi,mol_to_inchi)\n",
    "    \n",
    "    norm = matplotlib.colors.Normalize(vmin=-1, vmax=1) # minmax: (vmin=-1.0, vmax=1.28)： meanstd : (vmin=-0.2, vmax=1.0)\n",
    "    cmap = cm.get_cmap('bwr')\n",
    "\n",
    "    plt_colors = cm.ScalarMappable(norm=norm, cmap=cmap)\n",
    "    atom_colors = {idx: plt_colors.to_rgba(float(mol.GetAtomWithIdx( idx ).GetProp(\"atomNote_Score\"))) for idx in range(len(mol.GetAtoms()))}\n",
    "    \n",
    "    rdDepictor.Compute2DCoords(mol)\n",
    "    drawer = rdMolDraw2D.MolDraw2DSVG(1200, 800)\n",
    "    drawer.SetFontSize(120)\n",
    "    op = drawer.drawOptions()\n",
    "     \n",
    "    mol = rdMolDraw2D.PrepareMolForDrawing(mol)\n",
    "    drawer.DrawMolecule(mol, highlightAtoms=range(len(mol.GetAtoms())) if not highlightAtoms else highlightAtoms,\n",
    "                             highlightBonds=[],\n",
    "                             highlightBondColors=atom_colors,\n",
    "                             highlightAtomColors=atom_colors,legend = note)\n",
    "\n",
    "                             \n",
    "    drawer.FinishDrawing()\n",
    "    svg = drawer.GetDrawingText()\n",
    "    svg = svg.replace('svg:', '')\n",
    "    return (mol, weight_inchi, svg)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": []
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "\n",
    "\n",
    "def save_PNG_smilarymap(model,h,mol,pred_g,logit,key,save_dir):\n",
    "    logit = F.softmax(logit,dim = 1)[:,1].data.cpu().numpy()[0]\n",
    "\n",
    "    ligand_num = int(pred_g.ndata['V'].sum().data.cpu().numpy())\n",
    "    # h =  active_gs[idx][0]\n",
    "    for module in model.weight_and_sum.atom_weighting:\n",
    "        h = module(h)\n",
    "    mol, aw, svg = drawmol(mol, h.flatten().data.cpu().numpy()[:ligand_num],flag = ' prob: ' + str(round(logit,4))) \n",
    "    \n",
    "    weights = [float(mol.GetAtomWithIdx( idx ).GetProp(\"atomNote\")) for idx in range(len(mol.GetAtoms()))]\n",
    "    fig = SimilarityMaps.GetSimilarityMapFromWeights(mol,weights)\n",
    "    save_path = os.path.join(*[save_dir,os.path.join(*key.split('/')[-2:-1])]) \n",
    "    os.makedirs(save_path, exist_ok=True)\n",
    "\n",
    "    fig.savefig(os.path.join(save_path,key.split('/')[-1] + '_{}.png'.format(round(logit,4))),dpi=660,format='png',bbox_inches='tight')\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "\n",
    "import pickle \n",
    "from utils.equiscore_utils import *\n",
    "from tqdm import tqdm\n",
    "import utils.dataset_utils as dataset_utils\n",
    "from cairosvg import svg2png\n",
    "def save_PNG(model,h,mol,pred_g,logit,key,save_dir):\n",
    "    logit = F.softmax(logit,dim = 1)[:,1].data.cpu().numpy()[0]\n",
    "\n",
    "    ligand_num = int(pred_g.ndata['V'].sum().data.cpu().numpy())\n",
    "    # h =  active_gs[idx][0]\n",
    "    for module in model.weight_and_sum.atom_weighting:\n",
    "        h = module(h)\n",
    "    mol, aw, svg = drawmol(mol, h.flatten().data.cpu().numpy()[:ligand_num],flag = ' prob: ' + str(round(logit,4))) \n",
    "    save_path = os.path.join(*[save_dir,os.path.join(*key.split('/')[-2:-1])]) \n",
    "    os.makedirs(save_path, exist_ok=True)\n",
    "    svg2png(bytestring=svg,dpi=1200,write_to=os.path.join(save_path,key.split('/')[-1] + '_{}.png'.format(round(logit,4))))\n",
    "def getResult(model,test_sampler,test_dataloader,test_keys,save_dir):\n",
    "    all_attns = []\n",
    "    all_attns_res = []\n",
    "    # gs = []\n",
    "    none_fp  = 0\n",
    "    model.eval()\n",
    "    pbar = tqdm(test_dataloader)\n",
    "    with torch.no_grad():\n",
    "        test_losses,test_true,test_pred = [], [],[]\n",
    "        for i_batch, (g,full_g,Y) in enumerate(pbar):\n",
    "            key = test_keys[i_batch]\n",
    "            # get fp idx\n",
    "            with open(key,'rb') as f:\n",
    "                m1,m2,atompairs,types = pickle.load(f)\n",
    "                f.close()\n",
    "            n1,d1,adj1 = dataset_utils.get_mol_info(m1)\n",
    "            n2,d2,adj2 = dataset_utils.get_mol_info(m2)\n",
    "            H1 = get_atom_graphformer_feature(m1,FP = args.FP)\n",
    "            if args.virtual_aromatic_atom:\n",
    "                adj1,H1,d1,n1 = dataset_utils.add_atom_to_mol(m1,adj1,H1,d1,n1)\n",
    "            ################## ##############################\n",
    "            model.zero_grad()\n",
    "            g = g.to(args.local_rank)\n",
    "            full_g = full_g.to(args.local_rank)\n",
    "            Y = Y.long().to(args.local_rank)\n",
    "            h,pred_g,pred_full_g,logit = model.getAtt(g,full_g)\n",
    "            # save png\n",
    "            save_PNG(model,h,m1,pred_g,logit,key,save_dir = save_dir) # just for save figs\n",
    "            if len(atompairs) > 0 :\n",
    "                attns,attns_res = getAttnFP(pred_g,atompairs,n1) # change pred_g to pred_full_g as to get distance and IFP edges \n",
    "                all_attns.append(attns.data.cpu().numpy())\n",
    "                all_attns_res.append(attns_res.data.cpu().numpy())\n",
    "                none_fp += 1\n",
    "            pbar.set_description('fp num : {}-- ration : {}/{}'.format(none_fp,i_batch + 1,len(test_dataloader)))\n",
    "    return all_attns,all_attns_res\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "\n",
    "import glob\n",
    "save_dir = './figures/fep1_atom_weight/'\n",
    "test_keys = glob.glob('your data set path should be in here/*')\n",
    "# test_keys = [i for i in test_keys if '23486' in i]\n",
    "args.test_keys = test_keys\n",
    "args.test = True\n",
    "args.save_model = './workdir/official_weight/save_model_LeadOpt.pt'\n",
    "test_sampler,test_dataloader,test_keys = getDataLoader(args,key_type= 'all',nums = 10000)\n",
    "model = getModel(args)\n",
    "active_attns,all_attns_res= getResult(model,test_sampler,test_dataloader,test_keys,save_dir = save_dir)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": []
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "prolif",
   "language": "python",
   "name": "prolif"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.8.0"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 4
}
